注:本文逻辑提取自 React 16.13.x。随着 React 版本的更迭,事件系统的实现细节难免有调整,但其设计思想总是一脉相承的,你只要把握住核心逻辑即可。

# 回顾原生 DOM 下的事件流

在浏览器中,我们通过事件监听来实现 JS 和 HTML 之间的交互。一个页面往往会被绑定许许多多的事件,而页面接收事件的顺序,就是事件流。

W3C 标准约定了一个事件的传播过程要经过以下 3 个阶段:

  • 事件捕获阶段
  • 目标阶段
  • 事件冒泡阶段

当事件被触发时,首先经历的是一个捕获过程:事件会从最外层的元素开始“穿梭”,逐层“穿梭”到最内层元素,这个过程会持续到事件抵达它目标的元素(也就是真正触发这个事件的元素)为止;此时事件流就切换到了“目标阶段”——事件被目标元素所接收;然后事件会被“回弹”,进入到冒泡阶段——它会沿着来时的路“逆流而上”,一层一层再走回去。

# DOM 事件流下的性能优化思路:事件委托

在原生 DOM 中,事件委托(也叫事件代理)是一种重要的性能优化手段。这里我通过一道面试题,来快速地帮你回忆相关的知识。

请看下面这段代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <ul id="poem">
    <li>床前明月光</li>
    <li>疑是地上霜</li>
    <li>举头望明月</li>
    <li>低头思故乡</li>
    <li>锄禾日当午</li>
    <li>汗滴禾下土</li>
    <li>谁知盘中餐</li>
    <li>粒粒皆辛苦</li>
    <li>背不动了</li>
    <li>我背不动了</li>
  </ul>
</body>
</html>

问:在这段 HTML 渲染出的界面里,我希望做到点击每一个 li 元素,都能输出它内在的文本内容。你会怎么做?

一个比较直观的思路是让每一个 li 元素都去监听一个点击动作,按照这个思路写出来的代码是这样的:

<script>
  // 获取 li 列表
  var liList = document.getElementsByTagName('li')
  // 逐个安装监听函数
  for (var i = 0; i < liList.length; i++) {
    liList[i].addEventListener('click', function (e) {
      console.log(e.target.innerHTML)
    })
  }
</script>

我们当然可以像这样给 10 个 li 安装 10 次监听函数,但这样不仅累,开销也大。10 个监听函数做的还都是一模一样的事情,也不够优雅。怎么办呢?事件冒泡!

对于这 10 个 li 来说,无论点击动作发生在哪个 li 上,点击事件最终都会被冒泡到它们共同的父亲——ul 元素上去,所以我们完全可以让 ul 来帮忙感知这个点击事件。

既然 ul 可以帮忙感知事件,那它能不能帮忙处理事件呢?答案是能,因为我们有e.target。ul 元素可以通过事件对象中的 target 属性,拿到实际触发事件的那个元素,针对这个元素分发事件处理的逻辑,做到真正的“委托”。

按照这个思路,我们就可以丢掉 for 循环来写代码了,以下是用事件代理来实现同样效果的代码:

var ul = document.getElementById('poem')

ul.addEventListener('click', function(e){
  console.log(e.target.innerHTML)
})

这里再强调一下 e.target 这个属性,它指的是触发事件的具体目标,它记录着事件的源头。所以说,不管咱们的监听函数在哪一层执行,只要我拿到这个 e.target,就相当于拿到了真正触发事件的那个元素。拿到这个元素后,我们完全可以模拟出它的行为,实现无差别的监听效果。

像这样利用事件的冒泡特性,把多个子元素的同一类型的监听逻辑,合并到父元素上通过一个监听函数来管理的行为,就是事件委托。通过事件委托,我们可以减少内存开销、简化注册步骤,大大提高开发效率。

这绝妙的事件委托,正是 React合成事件的灵感源泉。

# React 事件系统是如何工作的

React 的事件系统沿袭了事件委托的思想。在 React 中,除了少数特殊的不可冒泡的事件(比如媒体类型的事件)无法被事件系统处理外,绝大部分的事件都不会被绑定在具体的元素上,而是统一被绑定在页面的 document 上。当事件在具体的 DOM 节点上被触发后,最终都会冒泡到 document 上,document 上所绑定的统一事件处理程序会将事件分发到具体的组件实例。

注意:在react17以后,绝大部分的事件都不会被绑定在具体的元素上,而是统一被绑定在页面的 root节点上,而不是document

在分发事件之前,React 首先会对事件进行包装,把原生 DOM 事件包装成合成事件

# 认识 React 合成事件

合成事件是 React 自定义的事件对象,它符合W3C规范,在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口。开发者们由此便不必再关注烦琐的兼容性问题,可以专注于业务逻辑的开发。

虽然合成事件并不是原生 DOM 事件,但它保存了原生 DOM 事件的引用。当你需要访问原生 DOM 事件对象时,可以通过合成事件对象的 e.nativeEvent 属性获取到它,如下图所示

e.nativeEvent 将会输出 MouseEvent 这个原生事件,如下图所示:

到这里,大家就对 React 事件系统的基本原理,包括合成事件的基本概念有了一定的了解。接下来,我们将在此基础上结合 React 源码和调用栈,对事件系统的工作流进行深入的拆解。

# React 事件系统工作流拆解

既然是事件系统,那就逃不出“事件绑定”和“事件触发”这两个关键动作。首先让我们一起来看看事件的绑定是如何实现的

# 事件的绑定

事件的绑定是在组件的挂载过程中完成的,具体来说,是在 completeWork 中完成的。这里需要你回忆起来的是 completeWork 中的以下三个动作:

completeWork 内部有三个关键动作:创建 DOM 节点(createInstance)将 DOM 节点插入到 DOM 树中(appendAllChildren)为 DOM 节点设置属性(finalizeInitialChildren)

其中“为 DOM 节点 设置属性” 这个环节,会遍历 FiberNodeprops key。当遍历到事件相关的 props 时,就会触发事件的注册链路。整个过程涉及的函数调用栈如下图所示:

这些函数之间是如何各司其职、打好“配合”的呢?请看下面这张工作流大图:

从图中可以看出,事件的注册过程是由 ensureListeningTo 函数开启的。在 ensureListeningTo 中,会尝试获取当前 DOM 结构中的根节点(这里指的是 document 对象),然后通过调用 legacyListenToEvent,将统一的事件监听函数注册到 document 上面

legacyListenToEvent 中,实际上是通过调用 legacyListenToTopLevelEvent 来处理事件和 document 之间的关系的。 legacyListenToTopLevelEvent 直译过来是“监听顶层的事件”,这里的“顶层”就可以理解为事件委托的最上层,也就是 document 节点。在 legacyListenToTopLevelEvent 中,有这样一段逻辑值得我们注意,请看下图:

listenerMap 是在 legacyListenToEvent 里创建/获取的一个数据结构,它将记录当前 document 已经监听了哪些事件。在 legacyListenToTopLevelEvent 逻辑的起点,会首先判断 listenerMap.has(topLevelType) 这个条件是否为 true

这里插播一个小的前置知识:topLevelTypelegacyListenToTopLevelEvent 的函数上下文中代表事件的类型,比如说我尝试监听的是一个点击事件,那么 topLevelType 的值就会是 click,如下图所示:

若事件系统识别到 listenerMap.has(topLevelType) 为 true,也就是当前这个事件 document 已经监听过了,那么就会直接跳过对这个事件的处理,否则才会进入具体的事件监听逻辑。如此一来,即便我们在 React 项目中多次调用了对同一个事件的监听,也只会在 document 上触发一次注册

为什么针对同一个事件,即便可能会存在多个回调,document 也只需要注册一次监听?因为 React最终注册到 document 上的并不是某一个 DOM 节点上对应的具体回调逻辑,而是一个统一的事件分发函数。这里我将断点打在事件监听函数的绑定动作上,请看下图:

在这段逻辑中,element 就是 document 这个 DOM 元素,如下图所示,它在 legacyListenToEvent 阶段被获取后,又被层层的逻辑传递到了这个位置。

addEventListener 就更不用多说了,它是原生 DOM 里专门用来注册事件监听器的接口。我们真正需要关注的是图中这个函数的前两个入参,首先看 eventType,它表示事件的类型,这里我监听的是一个点击事件,因此 eventType 就是 click(见下图的运行时输出结果)。

重点在 listener 上,前面刚说过,最终注册到 document 上的是一个统一的事件分发函数,这个函数到底长啥样?我们来看看,以下是运行时的 listener 输出结果:

可以看到,listener 本体是一个名为 dispatchDiscreteEvent 的函数。事实上,根据情况的不同,listener 可能是以下 3 个函数中的任意一个:

  • dispatchDiscreteEvent
  • dispatchUserBlockingUpdate
  • dispatchEvent

dispatchDiscreteEventdispatchUserBlockingUpdate 的不同,主要体现在对优先级的处理上,对事件分发动作倒没什么影响。无论是 dispatchDiscreteEvent 还是 dispatchUserBlockingUpdate,它们最后都是通过调用 dispatchEvent 来执行事件分发的。因此可以认为,最后绑定到 document 上的这个统一的事件分发函数,其实就是 dispatchEvent

那么 dispatchEvent 是如何实现事件分发的呢?

# 事件的触发

事件触发的本质是对 dispatchEvent 函数的调用。由于 dispatchEvent触发的调用链路较长,中间涉及的要素也过多,因此我们这里不再逐个跟踪函数的调用栈,直接来看核心工作流,请看下图:

工作流中前三步我们在前面都有所提及,对你来说相对难以理解的应该是 4、5、6 这三步,这三步也是我们接下来讲解的重点。

# 事件回调的收集与执行

我们借助一个 Demo 来理解这个过程,Demo 组件代码如下:

import React from 'react';
import { useState } from 'react'
function App() {
  const [state, setState] = useState(0)
  return (
    <div className="App">
      <div id="container" onClickCapture={() => console.log('捕获经过 div')} onClick={() => console.log('冒泡经过 div')} className="container">
        <p style={{ width: 128, textAlign: 'center' }}>
          {state}
        </p>
        <button style={{ width: 128 }} onClick={() => { setState(state + 1) }}>点击+1</button>
      </div>
    </div>
  );
}
export default App;

这个组件对应的界面如下图所示:

界面中渲染出来的是一行数字文本和一个按钮,每点击一下按钮,数字文本会 +1。在 JSX 结构中,监听点击事件的除了 button 按钮外,还有 id 为 container 的 div 元素,这个 div 元素同时监听了点击事件的冒泡和捕获。

App 组件对应的 Fiber 树结构如下图所示:

接下来我们借助这张 Fiber 树结构图来理解事件回调的收集过程。

首先我们来看收集过程对应的源码逻辑,这部分逻辑在 traverseTwoPhase 函数中,源码如下(解析在注释里):

function traverseTwoPhase(inst, fn, arg) {
  // 定义一个 path 数组
  var path = [];

  while (inst) {
    // 将当前节点收集进 path 数组
    path.push(inst);
    // 向上收集 tag===HostComponent 的父节点
    inst = getParent(inst);
  }
  var i;
  // 从后往前,收集 path 数组中会参与捕获过程的节点与对应回调
  for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }

  // 从前往后,收集 path 数组中会参与冒泡过程的节点与对应回调
  for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
}

traverseTwoPhase 函数做了以下三件事情。

1. 循环收集符合条件的父节点,存进 path 数组中

traverseTwoPhase会以当前节点(触发事件的目标节点)为起点,不断向上寻找 tag===HostComponent 的父节点,并将这些节点按顺序收集进 path 数组中。其中 tag===HostComponent 这个条件是在 getParent() 函数中管控的。

为什么一定要求 tag===HostComponent 呢?前面介绍渲染链路时,我们曾经讲过,HostComponent 是 DOM 元素对应的 Fiber 节点类型。此处限制 tag===HostComponent,也就是说只收集 DOM 元素对应的 Fiber 节点。之所以这样做,是因为浏览器只认识 DOM 节点,浏览器事件也只会在 DOM 节点之间传播,收集其他节点是没有意义的。

将这个过程对应到 Demo 示例的 Fiber 树中来看,button 节点是事件触发的起点,在它的父节点中,符合 tag===HostComponent 这个条件的只有 div#containerdiv.App(即下图高亮处)。

因此最后收集上来的 path 数组内容就是 div#containerdiv.Appbutton 节点自身(button 节点别忘了,它是 while 循环的起点,一开始就会被推进 path 数组),如下图所示:

2. 模拟事件在捕获阶段的传播顺序,收集捕获阶段相关的节点实例与回调函数

接下来,traverseTwoPhase 会从后往前遍历 path 数组,模拟事件的捕获顺序,收集事件在捕获阶段对应的回调与实例。

前面咱们说 path 数组是从子节点出发,向上收集得来的。所以说path 数组中子节点在前,祖先节点在后。

从后往前遍历 path 数组,其实就是从父节点往下遍历子节点,直至遍历到目标节点的过程,这个遍历顺序和事件在捕获阶段的传播顺序是一致的。在遍历的过程中,fn 函数会对每个节点的回调情况进行检查,若该节点上对应当前事件的捕获回调不为空,那么节点实例会被收集到合成事件的 _dispatchInstances 属性(也就是 SyntheticEvent._dispatchInstances)中去,事件回调则会被收集到合成事件的 _dispatchListeners 属性(也就是 SyntheticEvent._dispatchListeners) 中去,等待后续的执行。

3. 模拟事件在冒泡阶段的传播顺序,收集冒泡阶段相关的节点实例与回调函数

捕获阶段的工作完成后,traverseTwoPhase 会从后往前遍历 path 数组,模拟事件的冒泡顺序,收集事件在捕获阶段对应的回调与实例。

这个过程和步骤 2 基本是一样的,唯一的区别是对 path 数组的倒序遍历变成了正序遍历。既然倒序遍历模拟的是捕获阶段的事件传播顺序,那么正序遍历自然模拟的就是冒泡阶段的事件传播顺序。在正序遍历的过程中,同样会对每个节点的回调情况进行检查,若该节点上对应当前事件的冒泡回调不为空,那么节点实例和事件回调同样会分别被收集到 SyntheticEvent._dispatchInstances 和 SyntheticEvent._dispatchListeners 中去。

需要注意的是,当前事件对应的 SyntheticEvent 实例有且仅有一个,因此在模拟捕获和模拟冒泡这两个过程中,收集到的实例会被推入同一个 SyntheticEvent._dispatchInstances,收集到的事件回调也会被推入同一个 SyntheticEvent._dispatchListeners。

这样一来,我们在事件回调的执行阶段,只需要按照顺序执行 SyntheticEvent._dispatchListeners 数组中的回调函数,就能够一口气模拟出整个完整的 DOM 事件流,也就是 “捕获-目标-冒泡”这三个阶段。

接下来仍然是以 Demo 为例,我们来看看 button 上触发的点击事件对应的 SyntheticEvent 对象上的 _dispatchInstances 和 _dispatchListeners 各是什么内容,请看下图:

可以看出,_dispatchInstances 和 _dispatchListeners 两个数组中的元素是严格的一一对应关系,这确保了在回调的执行阶段,我们可以简单地通过索引来将实例与监听函数关联起来,实现事件委托的效果。同时,两个数组中元素的排序,完美地契合了 DOM 标准中“捕获-目标-冒泡”这三个阶段的事件传播顺序,真是妙啊!

阅读全文
Last Updated: 4/11/2024, 12:16:12 PM